cognito: a Cognito PKCE login flow for CLIs - #245
Conversation
Adds github.com/wego/pkg/cognito, the authorization-code-with-PKCE login a
command-line tool uses to obtain a human operator's tokens, plus a
cognito/storage subpackage that caches the token set in the OS keychain.
This is the CLI-side counterpart to http/jwt: that package verifies a token
arriving at a service, this one obtains one at a terminal. Extracted from the
payments repo's pay-admin CLI, where it was written against these constraints
from the start so the move needed no redesign.
Two properties are deliberate and worth preserving:
- No package-level mutable state. Every dependency -- the clock, the HTTP
client, the browser opener, the identity provider -- arrives through
Config, so one process can hold several environments live at once.
http/jwt keeps its JWKS URL and header in package globals and can
therefore serve exactly one issuer; that limitation is why this package
does not repeat the shape.
- Stdlib-only OAuth (bar Wego's string helpers). Hand-rolling the exchange
keeps every wire parameter visible and auditable, which matters more here
than the convenience an OAuth library would buy.
Kept as one module rather than splitting storage out: the split would have
forced cognito to be tagged before storage could require it, and every other
module in this repo requires tagged siblings with no replace directive. The
import paths are identical either way, so the only cost is that an
OAuth-only consumer also pulls go-keyring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
revive's unhandled-error rule flags a bare fmt.Fprint. The write genuinely cannot be acted on -- the browser tab is the only reader and the operator sees the real outcome in the terminal -- so the discard is explicit rather than implicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Login could only reach the operator by launching a browser, which rules out a headless shell, a terminal on a remote host, and any caller that wants to surface the URL its own way. Config.NoBrowser suppresses the launch and Config.PromptURL receives the authorize URL instead; everything else is unchanged, so the same PKCE challenge and state are sent and the code still arrives on the loopback listener. PromptURL is a func rather than a bool-plus-stdout so the library never writes to a stream the caller did not choose -- it can print, render a QR code, or hand the URL to another process. Setting NoBrowser without PromptURL is refused at validation: with no browser launched and no way to report the url, the operator has nothing to open. One limit is documented on the field rather than papered over: the redirect still lands on CallbackAddr, so a browser on a different machine than the CLI needs that port forwarded. Suppressing the launch does not move where the code is delivered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by CodeRabbit and claude[bot] on the payments PR this package was extracted from, and mirrored here so the two copies do not diverge before the payments one is deleted. postToken validated the three token strings but accepted any ExpiresIn. With expires_in absent, zero or negative, ExpiresAt landed on exactly now() and IsExpired subtracts a leeway on top, so a login that had just succeeded read as already expired -- sending the operator back through sign-in on their next command with no indication why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
Withdrawn because internal review context was posted to this public repository in error.
Config.httpClient returned a client with no CheckRedirect, so Go's default policy applied: follow up to ten redirects, re-sending the body verbatim on a 307 or 308. The token request carries the authorization code, the PKCE verifier and the client id on sign-in and the refresh token on renewal, so one redirect handed a complete credential set to whatever host the response named. It was also an injection route inwards, since the body that came back was parsed as the session to use. httpClient now returns a COPY with CheckRedirect set to refuse. Copying means a caller-supplied HTTPClient keeps its transport and timeout but cannot reinstate following, deliberately or by passing a client configured elsewhere, and the caller's own client is not mutated. A redirect from the token endpoint has no legitimate meaning here: TokenURL is an operator-configured Cognito domain that answers directly. Refusing turns it into the error it should be, reported with the endpoint and status via the existing status check. TestConfig_Defaults asserted the injected client was returned by identity, which is the behaviour that allowed the override to be bypassed. It now pins the copy semantics instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
A re-login that failed partway left a hybrid token set. Save wrote four separate keychain entries and Load gated on the access token, so a failure after the refresh token was written left the NEW refresh token beside the OLD access token, id token and expiry, and Load returned that mixture as a live session. Writing the access token last only protected a FIRST login, where there was nothing to mix with. The consequences are worse than a failed read: a stale expiry paired with a fresh refresh token makes the CLI believe a session is valid, and after an account switch one identity's id token can end up beside another's refresh token. Single-entry storage would fix it but does not fit. zalando/go-keyring shells out to /usr/bin/security on macOS and rejects any command over 4096 bytes (keyring_darwin.go); after its base64 expansion that leaves roughly 3 KB of secret per entry, and a combined set of Cognito JWTs runs to about that, so it would work in development and fail for operators with larger tokens. Fields therefore stay in their own entries. Atomicity comes from a commit pointer instead. Each token set is written into one of two slots, and a "current" entry names the slot that counts. Save fills the inactive slot and then moves the pointer, which is one small write and the only write that changes what Load sees, so a failure anywhere before it costs the new session and never the old one. Two slots rather than a counter keep the entry count fixed and mean a re-login never writes over the entries the live session is read from. Delete removes the pointer first, for the same reason, and clears both slots so a torn Save leaves no token material behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
A token set is read across five keychain entries, so another process could commit a replacement and clear the old slot between them. Load had already chosen that slot, and the next field read failed with "secret not found in keyring" even though a perfectly good session existed throughout. Two overlapping pay-admin processes is ordinary: a command running while a refresh fires, or two terminals on one namespace. The commit pointer is what makes this recoverable. On a failed field read Load now re-reads the pointer; if it has moved, the slot was superseded rather than broken, so it starts again on the slot that is now live. If the pointer has not moved the namespace really is inconsistent and the original error is reported unchanged, so a missing field is still named rather than disappearing into a generic retry. Bounded at three attempts. Each retry needs another process to commit a whole session in the gap, so exhausting them means a namespace being rewritten faster than it can be read, which no retry fixes. Immediate cleanup of the superseded slot is kept: leaving it would mean a stale refresh token living in the keychain until the next login, and the retry makes the deletion safe. Reported in review on wego/payments#2300 with a reproducing test. The regression here drives a real Save through the backend mid-read and asserts Load returns one whole session, never a mixture of the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
NoBrowser moved where the authorize URL is SHOWN, not where the code is delivered: the redirect still had to reach CallbackAddr on this machine, so a remote box needed `ssh -L`. With no browser and no port forward there was no way to sign in at all. Config.ReadRedirect closes that. Login shows the URL, the operator opens it anywhere, Cognito redirects to a loopback URL nothing is listening on, the browser shows a connection error, and its address bar holds ?code=...&state=... to copy back. No listener, no bound port. The device authorization grant would be the conventional answer and Cognito does not have one: the discovery document advertises no device_authorization_endpoint, /oauth2/device_authorization is 404, and the token endpoint answers a device_code grant with unsupported_grant_type. This is the substitute that needs no new Cognito configuration, because it is still authorization code with PKCE and only the redirect is carried by hand. State is still checked, and it is doing more work here than on the listener path: nothing about a pasted URL proves where it came from, so it is the only thing binding the code to this attempt. A bare code is refused for that reason, and a query carrying `error` reports the provider's refusal rather than a missing-code error. ReadRedirect implies NoBrowser. Launching a browser and then also asking for a paste would be a footgun, and where a local browser works the listener path is less work for the operator. CallbackAddr becomes optional on this path since nothing binds a port. The pasted URL carries a single-use authorization code, so it should not travel through a shared channel; that is documented on the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
|
Ready for another look. Since the last review this branch has three fixes and one addition, all with tests. Token endpoint no longer follows redirects ( Token sets commit atomically ( Concurrent reads survive a commit ( Paste-back sign-in ( Coverage is 95.3% on |
Round 4 of review on wego/payments#2300 found the two-slot layout still
allowed a hybrid credential set, by two schedules the round-3 Load retry
did not touch. Both are now regressions in this package, and both
reproduced the reported outputs before the fix.
Save/Save: two writers read the same live slot, both computed the same
other slot, and interleaved field writes into it. Both committed, so one
slot held one session's access token beside another's id and refresh
tokens ("second-access with first-id and first-refresh").
ABA: with two reusable slots the pointer could cycle A->B->A, so a
generation that HAD changed under an in-flight Load looked unchanged,
Load's moved-pointer check saw no move, and it returned fields it never
selected ("seed-access with second-id and second-refresh").
This is worse than untidy. The id token carries the operator identity
that admin writes are audited against, so a mismatched pair can
attribute a production change to the wrong person, which is why the
finding is HIGH rather than a tidiness nit.
Fields now live under a random generation name that is never reused,
with the pointer naming the live one. Concurrent writers are disjoint by
construction, so each generation is whole and the later commit simply
wins; and because a name never repeats, any change under a reader is
detectable, which removes ABA structurally rather than by timing.
Chosen over the process-shared lock the review also offered: this module
has no file-lock dependency and no platform-specific code, and a lock
would need both plus stale-holder handling on three platforms.
The cost is that a keychain cannot be enumerated, so only the
generations the pointer names can be reaped. The pointer therefore
remembers the one it replaced, and Delete clears both. A generation
orphaned by a crash mid-Save, or by two Saves overlapping, is not
reachable: it holds a superseded set no code path returns, and Cognito
refresh tokens expire, so it decays rather than accumulating. That
window is documented on Delete. Closing it entirely is what the locking
option would buy.
Save no longer fails when the pointer cannot be read. The old layout had
to know the live slot to avoid overwriting it; a fresh generation
collides with nothing, so an unreadable pointer costs only the chance to
reap and no longer blocks a sign-in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCCcwc4wtJgYKSFygyZGnR
|
@yanyi-wego review bump — this is now the only thing blocking the payments chain. Everything downstream is done: payments#2317 is approved and merged into #2300's branch, pennyworth#1525 is approved, merged and deployed to staging. payments#2300 carries both and is green with zero open threads. None of it can reach State here: build green on both jobs, zero open threads, 17 files, Every finding from that review is fixed, including round 4's storage races, which were the real ones:
Both were genuine holes in my own design, not test artifacts. I reproduced each one first and matched your reported outputs exactly before changing anything. The fix is commit-pointer storage where a generation name is never reused, which removes both failure modes rather than narrowing the window, and both of your reproductions are committed as regression tests. That replaced the earlier two-reusable-slot scheme. Also in since your review: the paste-back sign-in path for an unreachable callback port ( Merge order once you approve: this → tag |
yanyi-wego
left a comment
There was a problem hiding this comment.
Requesting changes for the credential transport blocker. Two non-blocking defects are noted inline.
Three findings from review round 5, all in the same area: what this package will accept as a place to send or receive OAuth credentials. TokenURL and AuthorizeURL could be cleartext http. The token endpoint receives the authorization code, the PKCE verifier and the client id on sign-in and the refresh token on renewal, so an http:// value handed the whole credential set to anyone on the path. Both now require https, with no loopback exemption: these are Cognito's own endpoints, served over https only, so http is a misconfiguration in every case. A URL embedding userinfo is refused too, since it would be logged and re-sent verbatim. CallbackAddr and RedirectURI could name a routable host, despite this package being loopback-only by design. Binding 0.0.0.0 would let anything that can reach the machine deliver a redirect to the single-use callback, and a routable redirect would send the code across the network to whoever answered. Both are now held to literal loopback — a name that merely resolves to 127.0.0.1 does not count, since resolution can change between the check and the request. Cleartext http stays allowed for the redirect and only there: RFC 8252 has a native app receive it on loopback, where TLS buys nothing but a certificate problem. The callback handler published its result before checking state, so a blind request to the predictable callback port could consume the one delivery the flow gets and abort a real sign-in that had not landed yet. State is now compared before anything is delivered, so an uncorrelated request is dropped and the genuine redirect is still accepted. This is a denial-of-sign-in guard, not the CSRF check — Login still compares state itself, which is what the paste-back path relies on. One consequence worth knowing: on the listener path a tampered state now surfaces as the wait expiring rather than "state mismatch", because it is never delivered. Two tests were rewritten to pin that contract. ReadRedirect ran synchronously, so a cancelled Login could not return while the reader was blocked on stdin. It now runs on a goroutine with Login selecting on ctx.Done(). That does not unblock the read itself and nothing can; the goroutine parks until the reader yields, then sends into a buffered channel and exits. The signature stays func() (string, error) so existing callers keep working. Every rule has a rejection test, plus the invalid-then-valid regression for the callback and a blocked-reader cancellation test. All three fixes are mutation-checked: reverting any one of them fails its own tests. The suite moved to httptest.NewTLSServer to match the new https rule rather than working around it. Verified the payments CLI still passes: it binds 127.0.0.1:8100, redirects to http://localhost:8100/callback, and builds both endpoints as https://<domain>/oauth2/*.
|
@yanyi-wego round 5 is answered in
All three are mutation-checked — reverting any one fails its own tests, not just the suite. I've resolved the blocking thread. I left the other two open on purpose, not because work is pending but because each carries a question back to you:
Two notes on things that changed beyond the fixes. The suite moved to Build is green. This is still the head of the chain: payments#2317 is merged into #2300's branch and pennyworth#1525 is merged and on staging, so #245 plus #2300 are the last two approvals before the tag and the merge. |
yanyi-wego
left a comment
There was a problem hiding this comment.
One blocking cancellation-contract issue remains inline.
Round 6, and the reviewer named a hazard the previous fix left open: a cancelled Login returned, but ReadRedirect could still be active on a caller-owned reader, so a retry raced the abandoned attempt for shared stdin. Whichever won, one of them saw a truncated or empty read. The review offered ctx-awareness OR a documented contract. Taking both, because they cover different halves and neither is sufficient alone. The signature is now func(ctx context.Context) (string, error), so a reader that can select on ctx is able to abandon the read instead of parking on the descriptor. Login still selects on ctx.Done() as well, because a reader that ignores ctx must not be able to pin Login — the ctx argument is what lets the read end, the select is what bounds Login. Neither can interrupt a read already blocked inside an uncooperative reader, and nothing in this package can, so the two obligations that genuinely fall to the caller are now written on the exported field: tear your own reader down on cancellation if you need the read to stop, and do not start another Login while a previous invocation may still be blocked in there. Breaking change to an exported field, taken deliberately now: this is the last moment before cognito/v0.1.0, after which it would not be free. The payments caller is updated in the same review round. TestLogin_PasteBackReaderSeesCancellation covers the new half — a cooperative reader observes Login's ctx being cancelled — alongside the existing TestLogin_PasteBackIsCancellable for the uncooperative case. Mutation-checked: passing context.Background() to the callback instead of ctx fails it. Race clean.
Found by running the CLI rather than the tests. A keychain carrying a pointer this build cannot parse made every command fail with parse the token pointer for "pay-admin/staging": invalid character 'a' looking for beginning of value which tells an operator nothing they can act on. The recovery exists and is always the same - Save rewrites the whole namespace, so signing out and back in fixes any unreadable pointer - but it is not guessable from a json error, so both pointer errors now say it. Still reported rather than silently repaired: quietly discarding a session store is not readPointer's decision, and a pointer that cannot be read may be the visible symptom of something worth knowing about. The specific value that surfaced this was "a", a bare slot name from the two-reusable-slot layout that generations replaced. That shape never shipped - v0.1.0 is the first release - so no released version can produce it and there is deliberately NO migration code for it; only pre-release builds on a developer's machine can have written one. It is covered as a test case because a keychain holding one still has to fail readably. Mutation-checked: removing the recovery sentence fails both cases.
Adds
github.com/wego/pkg/cognito: the Cognito authorization-code-with-PKCE login a command-line tool uses to obtain a human operator's tokens, plus acognito/storagesubpackage that caches the token set in the OS keychain.This is the CLI-side counterpart to
http/jwt. That package verifies a token arriving at a service; this one obtains a token at a terminal. Nothing in the repo covered the second half before.Extracted from the payments repo's
pay-adminCLI (wego/payments#2300), where it was written against these constraints from the start, so the move needed no redesign — package rename and import paths only.API
Headless sign-in
NoBrowsersuppresses the launch and hands the authorize URL toPromptURLinstead, for a headless shell, a terminal on a remote host, or a caller that wants to surface the URL its own way. Everything else is unchanged: same PKCE challenge, same state, code still delivered to the loopback listener.PromptURLis a func rather than a bool-plus-stdout so the library never writes to a stream the caller did not choose — it can print, render a QR code, or hand the URL to another process. SettingNoBrowserwithout it is refused at validation.One limit is documented on the field rather than papered over: the redirect still lands on
CallbackAddr, so a browser on a different machine than the CLI needs that port forwarded (ssh -L). Suppressing the launch does not move where the code is delivered.Two properties worth preserving
Config, so one process can hold several environments live at once.http/jwtkeeps its JWKS URL and header in package globals and can therefore serve exactly one issuer; payments' bo-refunds plan records that as the reason a second issuer became cross-team work. This package deliberately does not repeat the shape.wego/pkg/strings). Hand-rolling the exchange keeps every wire parameter visible and auditable, which matters more here than the convenience an OAuth library buys.Security-relevant behaviour
Verifier and
stateboth come fromcrypto/rand;stateis compared withsubtle.ConstantTimeCompareand a blank value on either side is a non-match. The challenge isS256. The callback listener binds loopback only and is single-use; neither the code norerror_descriptionis interpolated into the served HTML.Email()parses the id_token without verifying its signature — correct here because the token came from the token endpoint over TLS or the caller's own keychain, and it is documented as such — and it cannot panic on malformed input. No token, verifier, or state appears in any error string.Kept as one module
Splitting
storageinto its own module would forcecognitoto be tagged beforestoragecould require it, and every other module here requires tagged siblings with noreplace. Import paths are identical either way, so the only cost is that an OAuth-only consumer also pullsgo-keyring.Testing
go test ./...—cognito95.4%,cognito/storage94.7%. The uncovered lines are the ones that touch the OS: the browser exec and the real keychain adapter, both behind unexported seams so the logic around them is covered.NewMemorycarries the fullStorecontract tests so nothing pops a keychain prompt in CI.After merge
Tag
cognito/v0.1.0via./auto_version, then wego/payments#2300 drops its in-repo copy and requires the tag.sdc-clihas the same duplicated flow and can adopt it in a follow-up.